# Copyright (c) 2019 Presto Labs Pte. Ltd.
# Author: xguo

import enum
import json
import logging
import time
import typing
import urllib.parse

import requests
import requests.auth

from coin.exchange.base.kr_rest.auth_base import AuthKey
from coin.exchange.base.kr_rest.rest_client_base import RestUpdate
from coin.exchange.base.rest.request_nonce_manager import RequestNonceManager
from coin.exchange.util.async_http_client import AsyncHttpClient
from coin.exchange.util.order_checker import OrderAmountChecker
from coin.exchange.util.rate_limit import RateLimiter


class KorbitPlaceOrderStatus(enum.Enum):
  NAME_UNCHECKED = 'name_unchecked'
  UNDER_AGE = 'under_age'
  NOT_ENOUGH = 'not_enough'
  TOO_MANY_ORDERS = 'too_many_orders'
  SAVE_FAILURE = 'save_failure'  # Never happens, according to doc
  SUCCESS = 'success'


class KorbitCancelOrderStatus(enum.Enum):
  UNDER_AGE = 'under_age'
  NOT_FOUND = 'not_found'
  NOT_AUTHORIZED = 'not_authorized'
  ALREADY_FILLED = 'already_filled'
  PARTIALLY_FILLED = 'partially_filled'
  ALREADY_CANCELED = 'already_canceled'
  SUCCESS = 'success'


class KorbitOrderStatus(enum.Enum):
  UNFILLED = 'unfilled'
  PARTIALLY_FILLED = 'partially_filled'
  FILLED = 'filled'


class KorbitOrderType(enum.Enum):
  LIMIT = 'limit'
  MARKET = 'market'


class KorbitAuthGrantType(enum.Enum):
  PASSWORD = 'client_credentials'
  REFRESH_TOKEN = 'refresh_token'


class KorbitTimePeriod(enum.Enum):
  MINUTE = 'minute'
  HOUR = 'hour'
  DAY = 'day'


KORBIT_API_HOST = 'api.korbit.co.kr'
KORBIT_API3_HOST = 'api3.korbit.co.kr'


class KorbitAuthError(RuntimeError):
  pass


class KorbitAuth(requests.auth.AuthBase):
  def __init__(self, key_file, rest_request_gap_sec=None):
    self._key = AuthKey.from_file(key_file) if key_file else None
    self._token = None
    self._auth_value = None
    self._nonce_manager = RequestNonceManager(request_gap_sec=rest_request_gap_sec)

  @property
  def token_age(self):
    if self._token is not None:
      return time.time() - self._token['token_fetched_time']
    else:
      return None

  def need_refresh_token(self, tolerance_time_sec=30):
    if self.token_age is not None:
      return self._token['expires_in'] - self.token_age < tolerance_time_sec
    else:
      return True

  def has_token(self):
    return self._token is not None

  def is_ready(self):
    try:
      self.raise_if_invalid_token()
      return True
    except KorbitAuthError:
      return False

  def raise_if_invalid_token(self):
    if self._token is None:
      raise KorbitAuthError('token is not yet fetched!')

    if self._auth_value is None:
      raise KorbitAuthError('auth value not calculated!')

    if self.need_refresh_token():
      raise KorbitAuthError('token is expired! %s' % self._token)

  def set_token(self, token: dict, token_fetched_time: int):
    """
    token format:
    {
      "token_type": "Bearer",
      "access_token": "a long random string",
      "expires_in": 3600,
      "refresh_token": "a long random string"
    }
    """
    self._token = token
    self._token['token_fetched_time'] = token_fetched_time
    self._auth_value = "%s %s" % (token['token_type'], token['access_token'])

  def get_auth_params(self, grant_type: KorbitAuthGrantType):
    if grant_type == KorbitAuthGrantType.PASSWORD:
      params = [
          ('client_id', self._key.access_key),
          ('client_secret', self._key.secret_key),
          ('username', self._key.get_value('username')),
          ('password', self._key.get_value('password')),
          ('grant_type', grant_type.value),
      ]
    elif grant_type == KorbitAuthGrantType.REFRESH_TOKEN:
      params = [
          ('client_id', self._key.access_key),
          ('client_secret', self._key.secret_key),
          ('refresh_token', self._token['refresh_token']),
          ('grant_type', grant_type.value),
      ]
    else:
      raise ValueError('Unknown auth grant type %s', grant_type)
    return params

  def __call__(self, req: requests.PreparedRequest):
    if 'orderbook' in req.url or 'transactions' in req.url:
      pass
    elif 'oauth2/access_token' in req.url:  # token request
      grant_type = dict(urllib.parse.parse_qsl(req.body))['grant_type']
      params = self.get_auth_params(KorbitAuthGrantType(grant_type))
      req.prepare_body(params, files=None)
    else:
      self.raise_if_invalid_token()
      auth_header = {
          'Authorization': self._auth_value,
          'Cache-Control': 'no-cache',
      }
      req.headers.update(auth_header)
      if KORBIT_API3_HOST not in req.url and req.method == 'POST':
        params = urllib.parse.parse_qsl(req.body)
        nonce = str(self._nonce_manager.get_nonce())
        params.append(('nonce', nonce))
        req.prepare_body(params, files=None)
    return req


class KorbitSyncQueryer(object):
  def __init__(self,
               key_file,
               *,
               api_host=None,
               disable_ssl=False,
               rest_request_gap_sec=None,
               timeout=10):
    self._timeout = timeout
    self._auth = KorbitAuth(key_file, rest_request_gap_sec)
    scheme = 'http://' if disable_ssl else 'https://'
    self._api_host = scheme + (api_host or KORBIT_API_HOST)
    self._logger = logging.getLogger(__name__)

    # https://apidocs.korbit.co.kr/#first_section
    # API call rate limit
    # Creating/refreshing access token calls are limited to 60 calls per
    # 60 minutes; Ticker calls are limited to 60 calls per 60 seconds;
    # All other calls combined, are limited to 12 calls per 1 second.
    self.rate_limit = RateLimiter(11, 1.0)

  @property
  def auth(self):
    return self._auth

  def auth_request(self, method, path, params):
    assert method in ('GET', 'POST'), method
    data = None
    if method == 'POST':
      data = params
      params = None

    url = urllib.parse.urljoin(self._api_host, path)
    response = None
    try:
      response = requests.request(method,
                                  url,
                                  params=params,
                                  data=data,
                                  auth=self._auth,
                                  timeout=self._timeout)
      content = response.json()
      return content
    except requests.exceptions.RequestException:
      self._logger.exception('RequestException: %s')
      raise
    except json.JSONDecodeError:
      self._logger.exception('Json decode: %s', response.content)
      raise
    except Exception as e:
      self._logger.exception('Unknown exception, %s', e)
      raise

  def _query_nocheck(self, method, path, params):
    req_timestamp = time.time()
    content = self.auth_request(method, path, params)
    res_timestamp = time.time()
    status = requests.codes.ok
    update = RestUpdate(req_timestamp, res_timestamp, content, status)
    return update

  def query(self, method, path, params=None, disable_rate_limit=False):
    if not disable_rate_limit:
      while not self.rate_limit.check():
        time.sleep(0.1)
    else:
      self.rate_limit.check()
    return self._query_nocheck(method, path, params)


class KorbitAsyncQueryer(object):
  def __init__(
      self,
      key_file,
      *,
      api_host=None,
      disable_ssl=False,
      timeout=10,
      max_connections=4,
      rest_request_gap_sec=None,
  ):
    self._timeout = timeout
    self._http_client = AsyncHttpClient(max_connections=max_connections)
    self._auth = KorbitAuth(key_file, rest_request_gap_sec)
    scheme = 'http://' if disable_ssl else 'https://'
    self._api_host = scheme + (api_host or KORBIT_API_HOST)

  @property
  def auth(self):
    return self._auth

  def query(self, method, path, params=None):
    assert method in ('GET', 'POST'), method
    data = None
    if method == 'POST':
      data = params
      params = None

    url = urllib.parse.urljoin(self._api_host, path)
    future = self._http_client.fetch(method=method,
                                     url=url,
                                     params=params,
                                     data=data,
                                     auth=self._auth,
                                     timeout=self._timeout)
    return future


class KorbitNativePrivateClient(object):
  def __init__(
      self,
      *,
      key_file,
      api_host=None,
      disable_ssl=False,
      timeout=10,
      max_connections=8,
      use_async=False,
      rest_request_gap_sec=None,
  ):
    api_host = api_host or KORBIT_API_HOST
    if use_async:
      self._auth_queryer = KorbitAsyncQueryer(
          key_file,
          api_host=api_host,
          disable_ssl=disable_ssl,
          timeout=timeout,
          max_connections=max_connections,
          rest_request_gap_sec=rest_request_gap_sec,
      )
    else:
      self._auth_queryer = KorbitSyncQueryer(
          key_file,
          api_host=api_host,
          timeout=timeout,
          rest_request_gap_sec=rest_request_gap_sec,
      )
    self.order_amount_checker = OrderAmountChecker("KRW", 500, 50000000)

  def _query(self, method, path, params=None):
    return self._auth_queryer.query(method, path, params=params)

  def is_ready(self):
    return self._auth_queryer.auth.is_ready()

  def set_token(self, token_msg: dict, token_fetched_time: int):
    return self._auth_queryer.auth.set_token(token_msg, token_fetched_time)

  def has_token(self):
    return self._auth_queryer.auth.has_token()

  def need_refresh_token(self, tolerance_time_sec):
    return self._auth_queryer.auth.need_refresh_token(tolerance_time_sec)

  def place_buy_order(
      self,
      *,
      currency_pair: str,
      order_type: KorbitOrderType,
      price,
      coin_amount,
      fiat_amount=None,
  ):
    method = 'POST'
    path = 'v1/user/orders/buy'

    if order_type == KorbitOrderType.LIMIT:
      assert fiat_amount is None
      self.order_amount_checker.check_amount('KRW', float(price), float(coin_amount))
    elif order_type == KorbitOrderType.MARKET:
      assert price is None
      assert float(fiat_amount) > self.order_amount_checker.min_amount
      assert float(fiat_amount) < self.order_amount_checker.max_amount

    params = [('currency_pair', currency_pair), ('type', order_type.value), ('price', price),
              ('coin_amount', coin_amount), ('fiat_amount', fiat_amount)]
    update = self._query(method, path, params)
    return update

  def place_sell_order(
      self,
      *,
      currency_pair: str,
      order_type: KorbitOrderType,
      price,
      coin_amount,
  ):
    method = 'POST'
    path = 'v1/user/orders/sell'

    self.order_amount_checker.check_amount('KRW', float(price), float(coin_amount))
    params = [
        ('currency_pair', currency_pair),
        ('type', order_type.value),
        ('price', price),
        ('coin_amount', coin_amount),
    ]
    update = self._query(method, path, params)
    return update

  # Cancel Open Orders, can cancel multiple orders.
  def cancel_orders(self, *, currency_pair: str, order_id_list: typing.List[str]):
    method = 'POST'
    path = 'v1/user/orders/cancel'
    params = [
        ('currency_pair', currency_pair),
    ]
    for order_id in order_id_list:
      params.append(('id', order_id))
    update = self._query(method, path, params)
    return update

  # List Open Orders
  def query_open_orders(self, *, currency_pair, offset=0, limit=40):
    method = 'GET'
    path = 'v1/user/orders/open'
    params = [
        ('currency_pair', currency_pair),
        ('offset', offset),
        ('limit', limit),
    ]
    update = self._query(method, path, params)
    return update

  def query_user_orders(self,
                        *,
                        currency_pair: str,
                        status_list: typing.Union[typing.List[KorbitOrderStatus], None] = None,
                        order_id_list: typing.Union[typing.List[str], None] = None,
                        offset=0,
                        limit=40):
    method = 'GET'
    path = 'v1/user/orders'
    params = [
        ('currency_pair', currency_pair),
        ('offset', offset),
        ('limit', limit),
    ]

    if order_id_list is not None:
      for order_id in order_id_list:
        params.append(('id', order_id))

    if status_list is not None:
      for status in status_list:
        params.append(('status', status.value))

    update = self._query(method, path, params)
    return update

  def query_user_volume(self, currency_pair=None):
    # Korbit Rest API is case-sensitive
    method = 'GET'
    path = "v1/user/volume"
    params = [
        ('currency_pair', currency_pair),
    ]
    update = self._query(method, path, params)
    return update

  def query_user_balances(self):
    method = 'GET'
    path = 'v1/user/balances'
    update = self._query(method, path)
    return update

  def query_user_accounts(self):
    method = 'GET'
    path = "v1/user/accounts"
    update = self._query(method, path)
    return update

  def query_token(self, grant_type: KorbitAuthGrantType):
    method = 'POST'
    path = 'v1/oauth2/access_token'
    params = [
        ('grant_type', grant_type.value),
    ]
    update = self._query(method, path, params)
    return update

  def query_orderbook(self, currency_pair):
    method = 'GET'
    path = 'v1/orderbook'
    params = [
        ('currency_pair', currency_pair),
    ]
    return self._query(method, path, params)

  def query_filled_orders(
      self,
      *,
      currency_pair,
      time_period: typing.Union[None, KorbitTimePeriod] = None,
  ):
    method = 'GET'
    path = 'v1/transactions'
    if time_period is not None:
      time_period = time_period.value
    params = [
        ('currency_pair', currency_pair),
        ('time', time_period),
    ]
    return self._query(method, path, params=params)


if __name__ == "__main__":
  client = KorbitNativePrivateClient(
      key_file='../../coin_key/view/xunke00/korbit/view_key.json')
  update = client.query_token(KorbitAuthGrantType.PASSWORD)
  client.set_token(update.msg, update.res_timestamp)
  print(client.query_user_balances())
